TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1// Résumés intelligents : générés depuis les sources du cours (RAG), avec citations validées.2import { NextResponse } from "next/server";3import { z } from "zod";4import { apiError, parseBody, requireEnrollment } from "@/lib/api.ts";5import { assertSameOrigin, requireUser } from "@/lib/auth/session.ts";6import { all, run } from "@/lib/db/index.ts";7import { normalizeCourse } from "@/lib/learning/helpers.ts";8import { getPrompt } from "@/lib/prompts.ts";9import { completeChat } from "@/lib/openrouter/client.ts";10import { resolvePreset, getModel, estimateCost } from "@/lib/openrouter/registry.ts";11import { buildContext, type RetrievedChunk } from "@/lib/rag/search.ts";12import { resolveCitations } from "@/lib/rag/citations.ts";13import { checkBudget, logActivity, logUsage } from "@/lib/usage.ts";1415export async function GET(_req: Request, ctx: { params: Promise<{ course: string }> }) {16 try {17 const user = await requireUser();18 const course = normalizeCourse((await ctx.params).course);19 requireEnrollment(user.id, course);20 const summaries = all(21 `SELECT id, scope, ref, title, content, citations, created_by, created_at FROM summaries22 WHERE course_code = ? AND (owner_user_id IS NULL OR owner_user_id = ?) ORDER BY id DESC LIMIT 100`,23 course, user.id24 );25 return NextResponse.json({ summaries });26 } catch (e) {27 return apiError(e);28 }29}3031const schema = z.object({32 scope: z.enum(["week", "concept", "exam-prep"]),33 week: z.number().int().min(1).max(14).optional(),34 conceptSlug: z.string().max(120).optional(),35 style: z.enum(["ultra-court", "detaille", "avec-formules"]).default("detaille"),36});3738export async function POST(req: Request, ctx: { params: Promise<{ course: string }> }) {39 try {40 await assertSameOrigin();41 const user = await requireUser();42 const course = normalizeCourse((await ctx.params).course);43 requireEnrollment(user.id, course);44 const body = await parseBody(req, schema);45 const budget = checkBudget(user.id);46 if (!budget.ok) return NextResponse.json({ error: budget.reason }, { status: 429 });4748 // Sélection directe des fragments (pas de recherche : on veut la couverture du scope).49 let chunks: RetrievedChunk[] = [];50 let title = "";51 let ref = "";52 if (body.scope === "week") {53 if (!body.week) return NextResponse.json({ error: "Semaine requise." }, { status: 400 });54 chunks = all<RetrievedChunk>(55 `SELECT c.id, c.document_id, c.course_code, c.space, c.ref_type, c.ref_number, c.ref_label,56 c.section_title, c.title, c.content, c.box_types, c.week,57 d.title as doc_title, d.path as doc_path, d.filename, 1.0 as score58 FROM chunks c JOIN documents d ON d.id = c.document_id59 WHERE c.course_code = ? AND c.space = ? AND c.week = ? AND c.ref_type = 'slide'60 ORDER BY c.ref_number LIMIT 90`,61 course, `official-${course.toLowerCase()}`, body.week62 );63 title = `Résumé — Séance ${body.week}`;64 ref = `semaine-${body.week}`;65 } else if (body.scope === "concept") {66 if (!body.conceptSlug) return NextResponse.json({ error: "Concept requis." }, { status: 400 });67 const { hybridSearch } = await import("@/lib/rag/search.ts");68 const concept = all<{ name: string; description: string }>(69 "SELECT name, description FROM concepts WHERE course_code = ? AND slug = ?", course, body.conceptSlug70 )[0];71 if (!concept) return NextResponse.json({ error: "Concept inconnu." }, { status: 404 });72 chunks = await hybridSearch({ query: concept.name + ". " + concept.description, spaces: [`official-${course.toLowerCase()}`], k: 12 });73 title = `Résumé — ${concept.name}`;74 ref = body.conceptSlug;75 } else {76 chunks = all<RetrievedChunk>(77 `SELECT c.id, c.document_id, c.course_code, c.space, c.ref_type, c.ref_number, c.ref_label,78 c.section_title, c.title, c.content, c.box_types, c.week,79 d.title as doc_title, d.path as doc_path, d.filename, 1.0 as score80 FROM chunks c JOIN documents d ON d.id = c.document_id81 WHERE c.course_code = ? AND c.space = ? AND c.box_types != '' AND c.ref_type = 'slide'82 ORDER BY c.week, c.ref_number LIMIT 80`,83 course, `official-${course.toLowerCase()}`84 );85 title = "Résumé de préparation à l'examen";86 ref = "exam-prep";87 }88 if (!chunks.length) return NextResponse.json({ error: "Aucun contenu trouvé pour ce choix." }, { status: 404 });8990 const context = buildContext(chunks, 26_000);91 const styleInstr = {92 "ultra-court": "Résumé ULTRA-COURT : une page maximum, uniquement l'essentiel en listes serrées.",93 detaille: "Résumé DÉTAILLÉ : structuré par sous-thèmes, avec exemples brefs et erreurs fréquentes.",94 "avec-formules": "Résumé AXÉ FORMULES : chaque formule en LaTeX avec ses variables définies, ses conditions d'application et un mini-exemple chiffré.",95 }[body.style];96 const model = await resolvePreset("recommande");97 const t0 = Date.now();98 const { text, promptTokens, completionTokens } = await completeChat({99 model,100 messages: [101 {102 role: "system",103 content: [getPrompt("base-system"), course === "IMM1003" ? getPrompt("course-imm1003") : getPrompt("course-imm1033"), getPrompt("rag-grounding"), getPrompt("citation-policy")].join("\n\n---\n\n"),104 },105 {106 role: "user",107 content: `Produis « ${title} » pour le cours ${course}. ${styleInstr}\nTermine par une section « Questions d'auto-vérification » (3 questions sans réponse).\n\nMatériel du cours :\n${context.text}`,108 },109 ],110 maxTokens: 4000,111 temperature: 0.3,112 });113 const mi = await getModel(model);114 logUsage({ userId: user.id, model, kind: "summary", tokensIn: promptTokens, tokensOut: completionTokens, cost: mi ? estimateCost(mi, promptTokens, completionTokens) : 0, latencyMs: Date.now() - t0 });115116 const { cleaned, citations } = resolveCitations(text, context);117 const r = run(118 `INSERT INTO summaries (course_code, scope, ref, title, content, citations, owner_user_id, created_by)119 VALUES (?, ?, ?, ?, ?, ?, ?, 'ai')`,120 course, body.scope, ref, title, cleaned, JSON.stringify(citations), user.id121 );122 logActivity(user.id, "summary", course, 120);123 return NextResponse.json({ ok: true, id: Number(r.lastInsertRowid), title, content: cleaned, citations });124 } catch (e) {125 return apiError(e);126 }127}128